const { getAllCrawlersForArg } = require('./crawlers')
const { getDownloaderFor } = require('./downloaders')
const { getPlayer } = require('./players')
const { parentSymbol, isGroup, isTrack, getItemPath, getItemPathString, flattenGrouplike, countTotalItems, shuffleOrderOfGroups, cloneGrouplike } = require('./playlist-utils')
const { shuffleArray } = require('./general-util')
const processSmartPlaylist = require('./smart-playlist')
const UndoManager = require('./undo-manager')
const RecordStore = require('./record-store')

const {
  ui: {
    Dialog,
    DisplayElement,
    Label,
    Pane,
    WrapLabel,
    form: {
      Button,
      FocusElement,
      Form,
      ListScrollForm,
      TextInput,
    }
  },
  util: {
    ansi,
    telchars: telc,
    unichars: unic,
  }
} = require('tui-lib')

class AppElement extends FocusElement {
  constructor() {
    super()

    this.player = null
    this.recordStore = new RecordStore()
    this.undoManager = new UndoManager()
    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
    this.markGrouplike = {name: 'Marked', items: []}
    this.editMode = false

    // Crude hack...
    this.recordStore.app = this

    this.rootDirectory = process.env.HOME + '/.mtui'

    this.paneLeft = new Pane()
    this.addChild(this.paneLeft)

    this.paneRight = new Pane()
    this.addChild(this.paneRight)

    this.tabber = new Tabber()
    this.paneLeft.addChild(this.tabber)

    this.newGrouplikeListing()

    this.queueListingElement = new QueueListingElement(this.recordStore)
    this.setupCommonGrouplikeListingEvents(this.queueListingElement)
    this.queueListingElement.loadGrouplike(this.queueGrouplike)
    this.paneRight.addChild(this.queueListingElement)

    this.queueListingElement.on('queue', item => this.playGrouplikeItem(item))
    this.queueListingElement.on('remove', item => this.unqueueGrouplikeItem(item))
    this.queueListingElement.on('shuffle', () => this.shuffleQueue())
    this.queueListingElement.on('clear', () => this.clearQueue())
    this.queueListingElement.on('select main listing',
      () => this.root.select(this.tabber))

    this.playbackPane = new Pane()
    this.addChild(this.playbackPane)

    this.playbackInfoElement = new PlaybackInfoElement()
    this.playbackPane.addChild(this.playbackInfoElement)

    this.playbackInfoElement.on('seek back', () => this.seekBack(5))
    this.playbackInfoElement.on('seek ahead', () => this.seekAhead(5))
    this.playbackInfoElement.on('toggle pause', () => this.togglePause())

    // Dialogs

    this.openPlaylistDialog = new OpenPlaylistDialog()
    this.setupDialog(this.openPlaylistDialog)

    this.openPlaylistDialog.on('source selected', source => this.handlePlaylistSource(source))
    this.openPlaylistDialog.on('source selected (new tab)', source => this.handlePlaylistSource(source, true))

    this.alertDialog = new AlertDialog()
    this.setupDialog(this.alertDialog)

    /* Ignore this comment mostly :)  (Because menu isn't a child of pane,
       so we can append it to the app right away. Helps w/ handling ^C and
       stuff too.)
    // If the program were embedded, this.menu should probably be set to the
    // global menu object for that app (and everything should work fine).
    // As is, remember to append app.menu to root.
    */
    this.menu = new ContextMenu()
    this.addChild(this.menu)

    this.whereControl = new InlineListPickerElement('Where?', [
      {value: 'next', label: 'After current song'},
      {value: 'end', label: 'At end of queue'},
      {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
      {value: 'distribute-randomly', label: 'Distributed across queue randomly'}
    ])

    this.orderControl = new InlineListPickerElement('Order?', [
      {value: 'shuffle', label: 'Shuffle all'},
      {value: 'shuffle-groups', label: 'Shuffle order of groups'},
      {value: 'normal', label: 'In order'}
    ])
  }

  selected() {
    this.root.select(this.tabber)
  }

  newGrouplikeListing() {
    const grouplikeListing = new GrouplikeListingElement(this.recordStore)
    this.tabber.addTab(grouplikeListing)
    this.tabber.selectTab(grouplikeListing)

    grouplikeListing.on('download', item => this.downloadGrouplikeItem(item))
    grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item))
    grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el))
    grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))

    const updateListingsFor = item => {
      for (const grouplikeListing of this.tabber.tabberElements) {
        if (grouplikeListing.grouplike === item) {
          grouplikeListing.loadGrouplike(item, false)
        }
      }
    }

    grouplikeListing.on('remove', item => {
      if (this.editMode) {
        const parent = item[parentSymbol]
        const index = parent.items.indexOf(item)

        this.undoManager.pushAction({
          activate: () => {
            parent.items.splice(index, 1)
            delete item[parentSymbol]
            updateListingsFor(item)
            updateListingsFor(parent)
          },
          undo: () => {
            parent.items.splice(index, 0, item)
            item[parentSymbol] = parent
            updateListingsFor(item)
            updateListingsFor(parent)
          }
        })
      }
    })

    grouplikeListing.on('mark', item => {
      if (this.editMode) {
        if (!this.markGrouplike.items.includes(item)) {
          this.undoManager.pushAction({
            activate: () => {
              this.markGrouplike.items.push(item)
            },
            undo: () => {
              this.markGrouplike.items.pop()
            }
          })
        } else {
          const index = this.markGrouplike.items.indexOf(item)
          this.undoManager.pushAction({
            activate: () => {
              this.markGrouplike.items.splice(index, 1)
            },
            undo: () => {
              this.markGrouplike.items.splice(index, 0, item)
            }
          })
        }
      }
    })

    grouplikeListing.on('paste', (item, {where = 'below'} = {}) => {
      if (this.editMode && this.markGrouplike.items.length) {
        let parent, index

        if (where === 'above') {
          parent = item[parentSymbol]
          index = parent.items.indexOf(item)
        } else if (where === 'below') {
          parent = item[parentSymbol]
          index = parent.items.indexOf(item) + 1
        }

        this.undoManager.pushAction({
          activate: () => {
            parent.items.splice(index, 0, ...cloneGrouplike(this.markGrouplike).items.map(
              item => Object.assign({}, item, {[parentSymbol]: parent})
            ))
            updateListingsFor(parent)
          },
          undo: () => {
            parent.items.splice(index, this.markGrouplike.items.length)
            updateListingsFor(parent)
          }
        })
      }
    })

    this.setupCommonGrouplikeListingEvents(grouplikeListing)

    return grouplikeListing
  }

  setupCommonGrouplikeListingEvents(grouplikeListing) {
    // Sets up event listeners that are common to ordinary grouplike listings
    // (made by newGrouplikeListing) as well as the queue grouplike listing.

    const handleSelectFromPathElement = item => {
      const tabberListing = this.tabber.currentElement
      this.root.select(tabberListing)
      if (isGroup(item)) {
        tabberListing.loadGrouplike(item)
      } else if (item[parentSymbol]) {
        tabberListing.loadGrouplike(item[parentSymbol])
        tabberListing.selectAndShow(item)
      }
    }

    grouplikeListing.pathElement.on('select', item => handleSelectFromPathElement(item))
  }

  showMenuForItemElement(el) {
    const emitControls = play => () => {
      this.handleQueueOptions(item, {
        where: this.whereControl.curValue,
        order: this.orderControl.curValue,
        play: play
      })
    }

    const { item, isGroup, isMarked } = el
    const { editMode } = this
    const anyMarked = editMode && this.markGrouplike.items.length > 0
    this.menu.show({
      x: el.absLeft,
      y: el.absTop + 1,
      items: [
        // A label that just shows some brief information about the item.
        isGroup && {label:
          `(${item.name ? `"${item.name}"` : 'Unnamed'} -` +
          ` ${item.items.length} item${item.items.length === 1 ? '' : 's'}` +
          `, ${countTotalItems(item)} total` +
          ')'},

        // The actual controls!
        {divider: true},

        // TODO: Don't emit these on the element (and hence receive them from
        // the listing) - instead, handle their behavior directly. We'll want
        // to move the "mark"/"paste" (etc) code into separate functions,
        // instead of just defining their behavior inside the listing event
        // handlers.
        editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
        anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
        anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
        // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
        editMode && {divider: true},

        {element: this.whereControl},
        {element: this.orderControl},
        {label: 'Play!', action: emitControls(true)},
        {label: 'Queue!', action: emitControls(false)},
        {divider: true},

        {label: 'Remove from queue', action: () => this.unqueueGrouplikeItem(item)}
      ]
    })
  }

  async handlePlaylistSource(source, newTab = false) {
    if (this.openPlaylistDialog.visible) {
      this.openPlaylistDialog.close()
    }

    this.alertDialog.showMessage('Opening playlist...', false)

    let grouplike
    try {
      grouplike = await this.openPlaylist(source)
    } catch (error) {
      if (error === 'unknown argument') {
        this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + source)
      } else if (typeof error === 'string') {
        this.alertDialog.showMessage(error)
      } else {
        throw error
      }

      return
    }

    this.alertDialog.close()

    grouplike = await processSmartPlaylist(grouplike)

    if (newTab || !this.tabber.currentElement) {
      const grouplikeListing = this.newGrouplikeListing()
      grouplikeListing.loadGrouplike(grouplike)
    } else {
      this.tabber.currentElement.loadGrouplike(grouplike)
    }
  }

  openPlaylist(arg) {
    const crawlers = getAllCrawlersForArg(arg)

    if (crawlers.length === 0) {
      throw 'unknown argument'
    }

    const crawler = crawlers[0]

    return crawler(arg)
  }

  setupDialog(dialog) {
    dialog.visible = false
    this.addChild(dialog)

    dialog.on('cancelled', () => {
      dialog.close()
    })
  }

  async setup() {
    this.player = await getPlayer()

    if (!this.player) {
      return {
        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
      }
    }

    this.player.on('printStatusLine', data => {
      if (this.playingTrack) {
        this.playbackInfoElement.updateProgress(data)
      }
    })

    return true
  }

  async shutdown() {
    await this.player.kill()
    this.emit('quitRequested')
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = this.parent.contentH

    this.paneLeft.w = Math.max(Math.floor(0.8 * this.contentW), this.contentW - 80)
    this.paneLeft.h = this.contentH - 5
    this.paneRight.x = this.paneLeft.right
    this.paneRight.w = this.contentW - this.paneLeft.right
    this.paneRight.h = this.paneLeft.h
    this.playbackPane.y = this.paneLeft.bottom
    this.playbackPane.w = this.contentW
    this.playbackPane.h = this.contentH - this.playbackPane.y

    this.tabber.fillParent()
    this.tabber.fixLayout()
    this.queueListingElement.fillParent()
    this.playbackInfoElement.fillParent()
  }

  keyPressed(keyBuf) {
    if (keyBuf[0] === 0x03) {
      this.shutdown()
      return
    }

    if (telc.isRight(keyBuf)) {
      this.seekAhead(10)
    } else if (telc.isLeft(keyBuf)) {
      this.seekBack(10)
    } else if (telc.isSpace(keyBuf)) {
      this.togglePause()
    } else if (telc.isEscape(keyBuf)) {
      this.clearPlayingTrack()
    } else if (telc.isShiftUp(keyBuf) || telc.isCaselessLetter(keyBuf, 'p')) {
      this.playPreviousTrack(this.playingTrack, true)
    } else if (telc.isShiftDown(keyBuf) || telc.isCaselessLetter(keyBuf, 'n')) {
      this.playNextTrack(this.playingTrack, true)
    } else if (telc.isCharacter(keyBuf, '1') && this.tabber.selectable) {
      this.root.select(this.tabber)
    } else if (telc.isCharacter(keyBuf, '2') && this.queueListingElement.selectable) {
      this.root.select(this.queueListingElement)
    } else if (keyBuf.equals(Buffer.from([5]))) { // Ctrl-E
      this.editMode = !this.editMode
    } else if (this.editMode && keyBuf.equals(Buffer.from([14]))) { // ctrl-N
      this.newEmptyTab()
    } else if (keyBuf.equals(Buffer.from([15]))) { // ctrl-O
      this.openPlaylistDialog.open()
    } else if (keyBuf.equals(Buffer.from([20]))) { // ctrl-T
      this.cloneCurrentTab()
    } else if (keyBuf.equals(Buffer.from([23]))) { // ctrl-W
      this.closeCurrentTab()
    } else if (telc.isCharacter(keyBuf, 'u')) {
      this.undoManager.undoLastAction()
    } else if (telc.isCharacter(keyBuf, 'U')) {
      this.undoManager.redoLastUndoneAction()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['t'.charCodeAt(0)]))) {
      this.tabber.nextTab()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['T'.charCodeAt(0)]))) {
      this.tabber.previousTab()
    } else {
      super.keyPressed(keyBuf)
    }
  }

  newEmptyTab() {
    const listing = this.newGrouplikeListing()
    listing.loadGrouplike({
      name: 'New Playlist',
      items: []
    })
  }

  cloneCurrentTab() {
    const grouplike = this.tabber.currentElement.grouplike
    const listing = this.newGrouplikeListing()
    listing.loadGrouplike(grouplike)
  }

  closeCurrentTab() {
    const listing = this.tabber.currentElement
    let index
    this.undoManager.pushAction({
      activate: () => {
        index = this.tabber.currentElementIndex
        this.tabber.closeTab(this.tabber.currentElement)
      },
      undo: () => {
        this.tabber.addTab(listing, index)
        this.tabber.selectTab(listing)
      }
    })
  }

  shuffleQueue() {
    const queue = this.queueGrouplike
    const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
    const initialItems = queue.items.slice(0, index)
    const remainingItems = queue.items.slice(index)
    const newItems = initialItems.concat(shuffleArray(remainingItems))
    queue.items = newItems
    this.queueListingElement.buildItems()
  }

  clearQueue() {
    this.queueGrouplike.items = []
    this.queueListingElement.buildItems()
    this.queueListingElement.pathElement.showItem(null)
  }

  seekAhead(seconds) {
    this.player.seekAhead(seconds)
  }

  seekBack(seconds) {
    this.player.seekBack(seconds)
  }

  togglePause() {
    this.player.togglePause()
  }

  stopPlaying() {
    // We emit this so playTrack doesn't immediately start a new track.
    // We aren't *actually* about to play a new track.
    this.emit('playing new track')
    this.player.kill()
  }

  // TODO: I'd like to name/incorporate this function better.. for now it's
  // just directly moved from the old event listener on grouplikeListings for
  // 'queue'.
  handleQueueOptions(item, {where = 'end', order = 'normal', play = false} = {}) {
    if (isGroup(item)) {
      if (order === 'shuffle') {
        item = {items: shuffleArray(flattenGrouplike(item).items)}
      } else if (order === 'shuffle-groups') {
        item = shuffleOrderOfGroups(item)
      }
    }

    if (where === 'next' || where === 'end') {
      let afterItem = null
      if (where === 'next') {
        afterItem = this.playingTrack
      }

      this.queueGrouplikeItem(item, afterItem, {
        movePlayingTrack: order === 'normal'
      })
    } else if (where.startsWith('distribute-')) {
      this.distributeQueueGrouplikeItem(item, where.slice('distribute-'.length))
    }

    if (play) {
      this.playGrouplikeItem(item)
    }
  }

  queueGrouplikeItem(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
    const newTrackIndex = this.queueGrouplike.items.length

    // The position which new tracks should be added at, if afterItem is
    // passed.
    const afterIndex = afterItem && this.queueGrouplike.items.indexOf(afterItem)

    // Keeps track of how many tracks have been added; this is used so that
    // a whole group can be queued in order after a given item.
    let grouplikeOffset = 0

    const recursivelyAddTracks = item => {
      // For groups, just queue all children.
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyAddTracks(child)
        }

        return
      }

      const items = this.queueGrouplike.items

      // You can't put the same track in the queue twice - we automatically
      // remove the old entry. (You can't for a variety of technical reasons,
      // but basically you either have the display all bork'd, or new tracks
      // can't be added to the queue in the right order (because Object.assign
      // is needed to fix the display, but then you end up with a new object
      // that doesn't work with indexOf).)
      if (items.includes(item)) {
        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
        // is the one that's currently playing, we won't do anything with it
        // at all.
        if (!movePlayingTrack && item === this.playingTrack) {
          return
        }
        items.splice(items.indexOf(item), 1)
      }

      if (afterItem === 'FRONT') {
        items.unshift(item)
      } else if (afterItem) {
        items.splice(afterIndex + 1 + grouplikeOffset, 0, item)
      } else {
        items.push(item)
      }

      grouplikeOffset++
    }

    recursivelyAddTracks(topItem)
    this.queueListingElement.buildItems()

    // This is the first new track, if a group was queued.
    const newTrack = this.queueGrouplike.items[newTrackIndex]

    return newTrack
  }

  distributeQueueGrouplikeItem(grouplike, how = 'evenly') {
    const queue = this.queueGrouplike
    const newItems = flattenGrouplike(grouplike).items

    // Expressly do an initial pass and unqueue the items we want to queue -
    // otherwise they would mess with the math we do afterwords.
    for (const item of newItems) {
      if (queue.items.includes(item)) {
        /*
        if (!movePlayingTrack && item === this.playingTrack) {
          // NB: if uncommenting this code, splice item from newItems and do
          // continue instead of return!
          return
        }
        */
        queue.items.splice(queue.items.indexOf(item), 1)
      }
    }

    const distributeStart = queue.items.indexOf(this.playingTrack) + 1
    const distributeEnd = queue.items.length
    const distributeSize = distributeEnd - distributeStart

    const queueItem = (item, insertIndex) => {
      if (queue.items.includes(item)) {
        /*
        if (!movePlayingTrack && item === this.playingTrack) {
          return
        }
        */
        queue.items.splice(queue.items.indexOf(item), 1)
      } else {
        offset++
      }
      queue.items.splice(insertIndex, 0, item)
    }

    if (how === 'evenly') {
      let offset = 0
      for (const item of newItems) {
        const insertIndex = distributeStart + Math.floor(offset)
        queue.items.splice(insertIndex, 0, item)
        offset++
        offset += distributeSize / newItems.length
      }
    } else if (how === 'randomly') {
      const indexes = newItems.map(() => Math.floor(Math.random() * distributeSize))
      indexes.sort()
      for (let i = 0; i < newItems.length; i++) {
        const item = newItems[i]
        const insertIndex = distributeStart + indexes[i] + i
        queue.items.splice(insertIndex, 0, item)
      }
    }

    this.queueListingElement.buildItems()
  }

  unqueueGrouplikeItem(topItem) {
    // This function has support to unqueue groups - it removes all tracks in
    // the group recursively. (You can never unqueue a group itself from the
    // queue listing because groups can't be added directly to the queue.)

    const recursivelyUnqueueTracks = item => {
      // For groups, just unqueue all children. (Groups themselves can't be
      // added to the queue, so we don't need to worry about removing them.)
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyUnqueueTracks(child)
        }

        return
      }

      const items = this.queueGrouplike.items
      if (items.includes(item)) {
        items.splice(items.indexOf(item), 1)
      }
    }

    recursivelyUnqueueTracks(topItem)
    this.queueListingElement.buildItems()
  }

  async downloadGrouplikeItem(item) {
    if (isGroup(item)) {
      // TODO: Download all children (recursively), show a confirmation prompt
      // if there are a lot of items (remember to flatten).
      return
    }

    // Don't start downloading an item if we're already downloading it!
    if (this.recordStore.getRecord(item).downloading) {
      return
    }

    const arg = item.downloaderArg
    this.recordStore.getRecord(item).downloading = true
    try {
      return await getDownloaderFor(arg)(arg)
    } finally {
      this.recordStore.getRecord(item).downloading = false
    }
  }

  async playGrouplikeItem(item) {
    if (this.player === null) {
      throw new Error('Attempted to play before a player was loaded')
    }

    let playingThisTrack = true
    this.emit('playing new track')
    this.once('playing new track', () => {
      playingThisTrack = false
    })

    // If it's a group, play the first track.
    if (isGroup(item)) {
      item = flattenGrouplike(item).items[0]
    }

    // If there is no item (e.g. an empty group), well.. don't do anything.
    if (!item) {
      return
    }

    playTrack: {
      // No downloader argument? That's no good - stop here.
      // TODO: An error icon on this item, or something???
      if (!item.downloaderArg) {
        break playTrack
      }

      // If, by the time the track is downloaded, we're playing something
      // different from when the download started, assume that we just want to
      // keep listening to whatever new thing we started.

      const oldTrack = this.playingTrack

      const downloadFile = await this.downloadGrouplikeItem(item)

      if (this.playingTrack !== oldTrack) {
        return
      }

      await this.player.kill()
      this.recordStore.getRecord(item).playing = true
      this.playingTrack = item
      this.playbackInfoElement.updateTrack(item)

      if (this.queueListingElement.currentItem === oldTrack) {
        this.queueListingElement.selectAndShow(item)
      }

      try {
        await this.player.playFile(downloadFile)
      } finally {
        if (playingThisTrack || this.playingTrack !== item) {
          this.recordStore.getRecord(item).playing = false
        }
      }
    }

    // playingThisTrack now means whether the track played through to the end
    // (true), or was stopped by a different track being started (false).

    if (playingThisTrack) {
      this.playingTrack = null
      if (!this.playNextTrack(item)) {
        this.clearPlayingTrack()
      }
    }
  }

  playNextTrack(track, automaticallyQueueNextTrack = false) {
    if (!track) {
      return false
    }

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) {
      return false
    }
    queueIndex++

    if (queueIndex >= queue.items.length) {
      if (automaticallyQueueNextTrack) {
        const parent = track[parentSymbol]
        if (!parent) {
          return false
        }
        const index = parent.items.indexOf(track)
        const nextItem = parent.items[index + 1]
        if (!nextItem) {
          return false
        }
        this.queueGrouplikeItem(nextItem, false)
        queueIndex = queue.items.length - 1
      } else {
        return false
      }
    }

    this.playGrouplikeItem(queue.items[queueIndex])
    return true
  }

  playPreviousTrack(track, automaticallyQueuePreviousTrack = false) {
    if (!track) {
      return false
    }

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) {
      return false
    }
    queueIndex--

    if (queueIndex < 0) {
      if (automaticallyQueuePreviousTrack) {
        const parent = track[parentSymbol]
        if (!parent) {
          return false
        }
        const index = parent.items.indexOf(track)
        const previousItem = parent.items[index - 1]
        if (!previousItem) {
          return false
        }
        this.queueGrouplikeItem(previousItem, 'FRONT')
        queueIndex = 0
      } else {
        return false
      }
    }

    this.playGrouplikeItem(queue.items[queueIndex])
    return true
  }

  clearPlayingTrack() {
    this.playingTrack = null
    this.stopPlaying()
    this.playbackInfoElement.clearInfo()
  }
}

class GrouplikeListingElement extends Form {
  // TODO: This is a Form, which means that it captures the tab key. The result
  // of this is that you cannot use Tab to navigate the top-level application.
  // Accordingly, I've made AppElement a FocusElement and not a Form and re-
  // factored calls of addInput to addChild. However, I'm not sure that this is
  // the "correct" or most intuitive behavior. Should the tab key be usable to
  // navigate the entire interface? I don't know. I've gone with the current
  // behavior (GrouplikeListingElement as a Form) because it feels right at the
  // moment, but we'll see, I suppose.
  //
  // In order to let tab navigate through all UI elements (or rather, the top-
  // level application as well as GrouplikeListingElements, which are a sort of
  // nested Form), the AppElement would have to be changed to be a Form again
  // (replacing addChild with addInput where appropriate). Furthermore, while
  // the GrouplikeListingElement should stay as a Form subclass, it should be
  // modified so that it does not capture tab if there is no next element to
  // select, and vice versa for shift-tab and the previous element. This should
  // probably be implemented in tui-lib as a flag on Form (captureTabOnEnds,
  // or something).
  //
  // (PS AppElement apparently used a "this.form" property, instead of directly
  // inheriting from Form, apparently. That's more or less adjacent to the
  // point. It's removed now. You'll have to add it back, if wanted.)
  //
  // August 15th, 2018

  constructor(recordStore) {
    super()

    this.recordStore = recordStore

    this.grouplike = null
    this.recordStore = recordStore

    this.form = this.getNewForm()
    this.addInput(this.form)

    this.form.on('selected input', input => {
      if (input && this.pathElement) {
        this.pathElement.showItem(input.item)
      }
    })

    this.jumpElement = new ListingJumpElement()
    this.addChild(this.jumpElement)
    this.jumpElement.visible = false
    this.oldFocusedIndex = null // To restore to, if a jump is canceled.

    this.jumpElement.on('cancel', () => this.hideJumpElement(true))
    this.jumpElement.on('change', value => this.handleJumpValue(value, false))
    this.jumpElement.on('confirm', value => this.handleJumpValue(value, true))

    this.pathElement = new PathElement()
    this.addInput(this.pathElement)

    this.commentLabel = new WrapLabel()
    this.addChild(this.commentLabel)
  }

  getNewForm() {
    return new GrouplikeListingForm()
  }

  fixLayout() {
    this.commentLabel.w = this.contentW

    this.form.w = this.contentW
    this.form.h = this.contentH
    this.form.y = this.commentLabel.bottom
    this.form.h -= this.commentLabel.h
    this.form.h -= 1 // For the path element
    if (this.jumpElement.visible) this.form.h -= 1

    this.form.fixLayout() // Respond to being resized

    this.pathElement.y = this.contentH - 1
    this.pathElement.w = this.contentW

    this.jumpElement.y = this.pathElement.y - 1
    this.jumpElement.w = this.contentW
  }

  selected() {
    this.curIndex = 0
    this.root.select(this.form)
  }

  clicked(button) {
    if (button === 'left') {
      this.selected()
      return false
    }
  }

  get selectable() {
    return this.form.selectable
  }

  keyPressed(keyBuf) {
    if (telc.isBackspace(keyBuf)) {
      this.loadParentGrouplike()
    } else if (telc.isCharacter(keyBuf, '/') || keyBuf[0] === 6) { // '/', ctrl-F
      this.showJumpElement()
    } else {
      return super.keyPressed(keyBuf)
    }
  }

  loadGrouplike(grouplike, resetIndex = true) {
    this.grouplike = grouplike
    this.buildItems(resetIndex)
    if (this.root.select) this.hideJumpElement()
  }

  buildItems(resetIndex = false) {
    if (!this.grouplike) {
      throw new Error('Attempted to call buildItems before a grouplike was loaded')
    }

    this.commentLabel.text = this.grouplike.comment || ''

    const wasSelected = this.isSelected
    const form = this.form

    while (form.inputs.length) {
      form.removeInput(form.inputs[0], true, {fixLayout: false})
    }

    const parent = this.grouplike[parentSymbol]
    if (parent) {
      const upButton = new BasicGrouplikeItemElement(`Up (to ${parent.name || 'unnamed group'})`)
      upButton.on('pressed', () => this.loadParentGrouplike())
      form.addInput(upButton)
    }

    if (this.grouplike.items.length) {
      // Add an element for controlling this whole group. Particularly handy
      // for operating on the top-level group, which itself is not contained
      // within any groups (so you can't bworse a parent and access its menu
      // from there).
      if (!this.grouplike.isTheQueue) {
        const ownElement = new BasicGrouplikeItemElement(`This group: ${this.grouplike.name || '(Unnamed group)'}`)
        ownElement.item = this.grouplike
        ownElement.recordStore = this.recordStore
        ownElement.isGroup = true
        ownElement.on('pressed', () => {
          ownElement.emit('menu', ownElement)
        })
        this.addEventListeners(ownElement)
        form.addInput(ownElement)
      }

      // Add the elements for all the actual items within this playlist.
      for (const item of this.grouplike.items) {
        const itemElement = new InteractiveGrouplikeItemElement(item, this.recordStore)
        this.addEventListeners(itemElement)
        form.addInput(itemElement)
      }
    } else if (!this.grouplike.isTheQueue) {
      form.addInput(new BasicGrouplikeItemElement('(This group is empty)'))
    }

    if (wasSelected) {
      if (resetIndex) {
        form.curIndex = form.firstItemIndex
        form.scrollItems = 0
        form.updateSelectedElement()
      } else {
        this.root.select(form)
      }
    }

    // Just to make the selected-track-info bar fill right away (if it wasn't
    // already filled by a previous this.curIndex set).
    form.curIndex = form.curIndex

    this.fixAllLayout()
  }

  addEventListeners(itemElement) {
    for (const evtName of ['download', 'remove', 'mark', 'paste', 'browse', 'queue', 'unqueue', 'menu']) {
      itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
    }
  }

  loadParentGrouplike() {
    if (!this.grouplike) {
      return
    }

    const parent = this.grouplike[parentSymbol]
    if (parent) {
      const oldGrouplike = this.grouplike
      this.loadGrouplike(parent)

      const form = this.form
      const index = form.inputs.findIndex(inp => inp.item === oldGrouplike)
      if (typeof index === 'number') {
        form.curIndex = index
      } else {
        form.curIndex = form.firstItemIndex
      }
      form.updateSelectedElement()
      form.scrollSelectedElementIntoView()
    }
  }

  selectAndShow(item) {
    this.form.selectAndShow(item)
  }

  handleJumpValue(value, isConfirm) {
    // Don't perform the search if the user didn't enter anything.
    if (value.length) {
      const lower = value.toLowerCase()
      const getName = inp => (inp.item && inp.item.name) ? inp.item.name.toLowerCase().trim() : ''
      // TODO: Search past the current index, for repeated searches?
      const startsIndex = this.form.inputs.findIndex(inp => getName(inp).startsWith(lower))
      const includesIndex = this.form.inputs.findIndex(inp => getName(inp).includes(lower))
      const matchedIndex = startsIndex >= 0 ? startsIndex : includesIndex

      if (matchedIndex >= 0) {
        this.form.curIndex = matchedIndex
        this.form.scrollSelectedElementIntoView()
      } else {
        // TODO: Feedback that the search failed.. right now we just close the
        // jump-to menu, which might not be right.
      }
    }

    if (isConfirm) {
      this.hideJumpElement()
    }
  }

  showJumpElement() {
    this.oldFocusedIndex = this.form.curIndex
    this.jumpElement.visible = true
    this.root.select(this.jumpElement)
    this.fixLayout()
  }

  hideJumpElement(isCancel) {
    if (isCancel) {
      this.form.curIndex = this.oldFocusedIndex
      this.form.scrollSelectedElementIntoView()
    }
    this.jumpElement.visible = false
    this.root.select(this)
    this.fixLayout()
  }

  get tabberLabel() {
    if (this.grouplike) {
      return this.grouplike.name || 'Unnamed group'
    } else {
      return 'No group open'
    }
  }

  get currentItem() {
    const element = this.form.inputs[this.form.curIndex] || null
    return element && element.item
  }
}

class GrouplikeListingForm extends ListScrollForm {
  constructor() {
    super('vertical')

    this.captureTab = false
  }

  set curIndex(newIndex) {
    this._curIndex = newIndex
    this.emit('selected input', this.inputs[this.curIndex])
  }

  get curIndex() {
    return this._curIndex
  }

  get firstItemIndex() {
    return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
  }

  selectAndShow(item) {
    const index = this.inputs.findIndex(inp => inp.item === item)
    if (index >= 0) {
      this.curIndex = index
      if (this.isSelected) {
        this.updateSelectedElement()
      }
      this.scrollSelectedElementIntoView()
    }
  }
}

class BasicGrouplikeItemElement extends Button {
  constructor(text) {
    super()

    this.text = text
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1
  }

  drawTo(writable) {
    const isCurrentInput = this.parent.inputs[this.parent.curIndex] === this
    // This line's commented out for now, so it'll show as selected (but
    // dimmed) even if you don't have the listing selected. To change that,
    // uncomment this and add it to the isCurrentInput line.
    // const isListingSelected = this.parent.parent.isSelected
    const isSelfSelected = this.isSelected

    if (isSelfSelected) {
      writable.write(ansi.invert())
    } else if (isCurrentInput) {
      writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.A_DIM]))
    }

    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    this.drawX = this.x
    this.writeStatus(writable)
    writable.write(this.text.slice(0, this.w - this.drawX))
    this.drawX += ansi.measureColumns(this.text)
    writable.write(' '.repeat(Math.max(0, this.w - this.drawX)))

    writable.write(ansi.resetAttributes())
  }

  writeStatus(writable) {
    // Add a couple spaces. This is less than the padding of the status text
    // of elements which represent real playlist items; that's to distinguish
    // "fake" rows from actual playlist items.
    writable.write('  ')
    this.drawX += 2
  }

  keyPressed(keyBuf) {
    // This function is overridden by InteractiveGrouplikeItemElement, but
    // it's still specified here that only enter counts as an action key.
    // By default for buttons, the space key also works, but since in this app
    // space is generally bound to mean "pause" instead of "select", we don't
    // check if space is pressed here.
    if (telc.isEnter(keyBuf)) {
      this.emit('pressed')
    }
  }

  clicked(button) {
    super.clicked(button)
    return false
  }
}

class InlineListPickerElement extends FocusElement {
  // And you thought my class names couldn't get any worse...
  // This is an element that looks something like the following:
  //    Fruit?  [Apple]
  // (Imagine that "[Apple]" just looks like "Apple" written in blue text.)
  // If you press the element (like a button), it'll pick the next item in its
  // list of options, like "Banana" or "Canteloupe" in this example. The arrow
  // keys also work to move through the list. You typically don't want to put
  // too many items in the list, since there's no visual way of telling what's
  // next or previous. (That's the point, it's inline.) This element is mainly
  // useful in forms or ContextMenus.

  constructor(labelText, options) {
    super()
    this.labelText = labelText
    this.options = options
    this.curIndex = 0
  }

  fixLayout() {
    // We want to fill the parent's width, but also fit ourselves, so we need
    // to determine the ideal width which would fit us but not leave extra
    // space.
    const longestOptionLength = this.options.reduce(
      (soFar, { label }) => Math.max(soFar, ansi.measureColumns(label)), 0)
    const idealWidth = (
      ansi.measureColumns(this.labelText) + longestOptionLength + 4)

    // Then we use whichever is greater - our ideal width or the width of the
    // parent - as our own width. The parent should respect our needs by
    // growing if necessary. :)  (ContextMenu does this, which is where you'd
    // typically embed this element.)
    // I shall fill you, parent, even beyond your own bounds!!!
    this.w = Math.max(this.parent.contentW, idealWidth)

    // Height is always just 1.
    this.h = 1
  }

  drawTo(writable) {
    if (this.isSelected) {
      writable.write(ansi.invert())
    }

    const curOption = this.options[this.curIndex].label.toString()
    let drawX = 0
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    writable.write(this.labelText + ' ')
    drawX += ansi.measureColumns(this.labelText) + 1

    writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
    writable.write(' ' + curOption + ' ')
    drawX += ansi.measureColumns(curOption) + 2

    writable.write(ansi.setForeground(ansi.C_RESET))
    writable.write(' '.repeat(Math.max(0, this.w - drawX)))

    writable.write(ansi.resetAttributes())
  }

  keyPressed(keyBuf) {
    if (telc.isSelect(keyBuf) || telc.isRight(keyBuf)) {
      this.nextOption()
    } else if (telc.isLeft(keyBuf)) {
      this.previousOption()
    } else {
      return true
    }
  }

  clicked(button) {
    if (button === 'left') {
      if (this.isSelected) {
        this.nextOption()
      } else {
        this.root.select(this)
      }
    } else if (button === 'scroll-up') {
      this.previousOption()
    } else if (button === 'scroll-down') {
      this.nextOption()
    } else {
      return true
    }
    return false
  }

  nextOption() {
    this.curIndex++
    if (this.curIndex === this.options.length) {
      this.curIndex = 0
    }
  }

  previousOption() {
    this.curIndex--
    if (this.curIndex < 0) {
      this.curIndex = this.options.length - 1
    }
  }

  get curValue() {
    return this.options[this.curIndex].value
  }
}

class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
  constructor(item, recordStore) {
    super(item.name)
    this.item = item
    this.recordStore = recordStore
  }

  drawTo(writable) {
    this.text = this.item.name
    super.drawTo(writable)
  }

  keyPressed(keyBuf) {
    if (telc.isCaselessLetter(keyBuf, 'd')) {
      this.emit('download')
    } else if (telc.isCharacter(keyBuf, 'q')) {
      this.emit('queue', {where: 'end'})
    } else if (telc.isCharacter(keyBuf, 'Q')) {
      this.emit('queue', {where: 'next'})
    } else if (telc.isEnter(keyBuf)) {
      if (isGroup(this.item)) {
        this.emit('browse')
      } else {
        this.emit('queue', {where: 'next', play: true})
      }
    } else if (telc.isCaselessLetter(keyBuf, 'x')) {
      this.emit('remove')
    } else if (telc.isCaselessLetter(keyBuf, 'm') || telc.isCaselessLetter(keyBuf, 'f')) {
      this.emit('menu', this)
    }
  }

  clicked(button) {
    if (button === 'left') {
      if (this.isSelected) {
        if (this.isGroup) {
          this.emit('browse')
        } else {
          this.emit('queue', {where: 'next', play: true})
        }
      } else {
        this.parent.selectInput(this)
      }
      return false
    } else if (button === 'right') {
      this.parent.selectInput(this)
      this.emit('menu', this)
      return false
    }
  }

  writeStatus(writable) {
    if (this.isGroup) {
      // The ANSI attributes here will apply to the rest of the line, too.
      // (We don't reset the active attributes until after drawing the rest of
      // the line.)
      writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT]))
    }

    this.drawX += 3

    const braille = '⠈⠐⠠⠄⠂⠁'
    const brailleChar = braille[Math.floor(Date.now() / 250) % 6]

    const record = this.recordStore.getRecord(this.item)

    if (this.isMarked) {
      writable.write('M')
    } else {
      writable.write(' ')
    }

    if (this.isGroup) {
      writable.write('G')
    } else if (record.downloading) {
      writable.write(braille[Math.floor(Date.now() / 250) % 6])
    } else if (record.playing) {
      writable.write('\u25B6')
    } else {
      writable.write(' ')
    }

    writable.write(' ')
  }

  get isMarked() {
    return this.recordStore.app.editMode && this.recordStore.app.markGrouplike.items.includes(this.item)
  }

  get isGroup() {
    return isGroup(this.item)
  }

  get isTrack() {
    return isTrack(this.item)
  }
}

class ListingJumpElement extends Form {
  constructor() {
    super()

    this.label = new Label('Jump to: ')
    this.addChild(this.label)

    this.input = new TextInput()
    this.addInput(this.input)

    this.input.on('confirm', value => this.emit('confirm', value))
    this.input.on('change', value => this.emit('change', value))
    this.input.on('cancel', () => this.emit('cancel'))
  }

  selected() {
    this.input.value = ''
    this.input.keepCursorInRange()
    this.root.select(this.input)
  }

  fixLayout() {
    this.input.x = this.label.right
    this.input.w = this.contentW - this.input.x
  }

  keyPressed(keyBuf) {
    const val = super.keyPressed(keyBuf)
    if (typeof val !== 'undefined') {
      return val
    }

    // Don't bubble escape.
    if (telc.isEscape(keyBuf)) {
      return false
    }
  }
}

class PathElement extends ListScrollForm {
  constructor() {
    // TODO: Once we've got the horizontal scrollbar draw working, perhaps
    // enable this? Well probably not. This is more a TODO to just, well,
    // implement that horizontal scrollbar drawing anyway.
    super('horizontal', false)
    this.captureTab = false
  }

  showItem(item) {
    while (this.inputs.length) {
      this.removeInput(this.inputs[0])
    }

    if (!isTrack(item) && !isGroup(item)) {
      return
    }

    const itemPath = getItemPath(item)
    const parentPath = itemPath.slice(0, -1)

    for (const pathItem of parentPath) {
      const isFirst = pathItem === parentPath[0]
      const element = new PathItemElement(pathItem, isFirst)
      element.on('select', () => this.emit('select', pathItem))
      element.fixLayout()
      this.addInput(element)
    }

    this.curIndex = this.inputs.length - 1

    this.scrollToEnd()
    this.fixLayout()
  }
}

class PathItemElement extends FocusElement {
  constructor(item, isFirst) {
    super()

    this.item = item
    this.isFirst = isFirst

    this.arrowLabel = new Label(isFirst ? 'In: ' : ' > ')
    this.addChild(this.arrowLabel)

    this.button = new Button(item.name || '(Unnamed)')
    this.addChild(this.button)

    this.button.on('pressed', () => {
      this.emit('select')
    })
  }

  selected() {
    this.root.select(this.button)
  }

  clicked(button) {
    if (button === 'left') {
      this.emit('select')
    }
  }

  fixLayout() {
    this.button.fixLayout()
    this.arrowLabel.fixLayout()
    this.w = this.button.w + this.arrowLabel.w
    this.button.x = this.arrowLabel.right
    this.h = 1
  }
}

class QueueListingElement extends GrouplikeListingElement {
  getNewForm() {
    return new QueueListingForm()
  }

  keyPressed(keyBuf) {
    if (telc.isCaselessLetter(keyBuf, 's')) {
      this.emit('shuffle')
    } else if (telc.isCaselessLetter(keyBuf, 'c')) {
      this.emit('clear')
    } else {
      return super.keyPressed(keyBuf)
    }
  }
}

class QueueListingForm extends GrouplikeListingForm {
  updateSelectedElement() {
    if (this.inputs.length) {
      super.updateSelectedElement()
    } else {
      this.emit('select main listing')
    }
  }
}

class PlaybackInfoElement extends DisplayElement {
  constructor() {
    super()

    this.progressBarLabel = new Label('')
    this.addChild(this.progressBarLabel)

    this.progressTextLabel = new Label('')
    this.addChild(this.progressTextLabel)

    this.trackNameLabel = new Label('')
    this.addChild(this.trackNameLabel)

    this.downloadLabel = new Label('')
    this.addChild(this.downloadLabel)
  }

  fixLayout() {
    const centerX = el => el.x = Math.round((this.w - el.w) / 2)

    this.trackNameLabel.y = 0
    this.progressBarLabel.y = 1
    this.progressTextLabel.y = this.progressBarLabel.y
    this.downloadLabel.y = 2

    if (this.currentTrack) {
      const dl = this.currentTrack.downloaderArg
      let dlText = dl.slice(Math.max(dl.length - this.w + 20, 0))
      if (dlText !== dl) {
        dlText = unic.ELLIPSIS + dlText
      }
      this.downloadLabel.text = `(From: ${dlText})`
    }

    centerX(this.progressTextLabel)
    centerX(this.trackNameLabel)
    centerX(this.downloadLabel)
  }

  clicked(button) {
    if (button === 'scroll-up') {
      this.emit('seek back')
    } else if (button === 'scroll-down') {
      this.emit('seek ahead')
    } else if (button === 'left') {
      this.emit('toggle pause')
    }
  }

  updateProgress({timeDone, timeLeft, duration, lenSecTotal, curSecTotal}) {
    this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
    this.progressTextLabel.text = timeDone + ' / ' + duration
    this.fixLayout()
  }

  updateTrack(track) {
    this.currentTrack = track
    this.trackNameLabel.text = track.name
    this.progressBarLabel.text = ''
    this.progressTextLabel.text = '(Starting..)'
    this.fixLayout()
  }

  clearInfo() {
    this.currentTrack = null
    this.progressBarLabel.text = ''
    this.progressTextLabel.text = ''
    this.trackNameLabel.text = ''
    this.downloadLabel.text = ''
    this.fixLayout()
  }
}

class OpenPlaylistDialog extends Dialog {
  constructor() {
    super()

    this.label = new Label('Enter a playlist source:')
    this.pane.addChild(this.label)

    this.form = new Form()
    this.pane.addChild(this.form)

    this.input = new TextInput()
    this.form.addInput(this.input)

    this.button = new Button('Open')
    this.form.addInput(this.button)

    this.buttonNewTab = new Button('..in New Tab')
    this.form.addInput(this.buttonNewTab)

    this.button.on('pressed', () => {
      if (this.input.value) {
        this.emit('source selected', this.input.value)
      }
    })

    this.buttonNewTab.on('pressed', () => {
      if (this.input.value) {
        this.emit('source selected (new tab)', this.input.value)
      }
    })
  }

  opened() {
    this.input.setValue('')
    this.form.curIndex = 0
    this.form.updateSelectedElement()
  }

  fixLayout() {
    super.fixLayout()

    this.pane.w = Math.min(60, this.contentW)
    this.pane.h = 6
    this.pane.centerInParent()

    this.label.centerInParent()
    this.label.y = 0

    this.form.w = this.pane.contentW
    this.form.h = 2
    this.form.y = 1

    this.input.w = this.form.contentW

    this.button.centerInParent()
    this.button.y = 1

    this.buttonNewTab.centerInParent()
    this.buttonNewTab.y = 2
  }

  selected() {
    this.root.select(this.form)
  }
}

class AlertDialog extends Dialog {
  constructor() {
    super()

    this.label = new Label()
    this.pane.addChild(this.label)

    this.button = new Button('Close')
    this.button.on('pressed', () => {
      if (this.canClose) {
        this.emit('cancelled')
      }
    })
    this.pane.addChild(this.button)
  }

  selected() {
    this.root.select(this.button)
  }

  showMessage(message, canClose = true) {
    this.canClose = canClose
    this.label.text = message
    this.button.text = canClose ? 'Close' : '(Hold on...)'
    this.open()
  }

  fixLayout() {
    super.fixLayout()

    this.pane.w = Math.min(this.label.w + 4, this.contentW)
    this.pane.h = 4
    this.pane.centerInParent()

    this.label.centerInParent()
    this.label.y = 0

    this.button.fixLayout()
    this.button.centerInParent()
    this.button.y = 1
  }

  keyPressed() {
    // Don't handle the escape key.
  }
}

class Tabber extends FocusElement {
  constructor() {
    super()

    this.tabberElements = []
    this.currentElementIndex = 0

    this.listElement = new TabberList(this)
    this.addChild(this.listElement)

    this.listElement.on('selected', item => this.selectTab(item))
    this.listElement.on('next tab', () => this.nextTab())
    this.listElement.on('previous tab', () => this.previousTab())
  }

  fixLayout() {
    const el = this.currentElement
    if (el) {
      // Only make space for the tab list if there's more than one tab visible.
      // (The tab list isn't shown if there's only one.)
      if (this.tabberElements.length > 1) {
        el.w = this.contentW
        el.h = this.contentH - 1
        el.x = 0
        el.y = 1
      } else {
        el.fillParent()
        el.x = 0
        el.y = 0
      }
      el.fixLayout()
    }

    if (this.tabberElements.length > 1) {
      this.listElement.visible = true
      this.listElement.w = this.contentW
      this.listElement.h = 1
      this.listElement.fixLayout()
    } else {
      this.listElement.visible = false
    }
  }

  addTab(element, index = this.currentElementIndex) {
    element.visible = false
    this.tabberElements.splice(index + 1, 0, element)
    this.addChild(element, index + 1)
    this.listElement.buildItems()
  }

  nextTab() {
    this.currentElementIndex++
    this.updateVisibleElement()
  }

  previousTab() {
    this.currentElementIndex--
    this.updateVisibleElement()
  }

  selectTab(element) {
    if (!this.tabberElements.includes(element)) {
      throw new Error('That tab does not exist! (Perhaps it was removed, somehow, or was never added?)')
    }

    this.currentElementIndex = this.tabberElements.indexOf(element)
    this.updateVisibleElement()
  }

  closeTab(element) {
    if (!this.tabberElements.includes(element)) {
      return
    }

    const index = this.tabberElements.indexOf(element)
    this.tabberElements.splice(index, 1)
    if (index <= this.currentElementIndex) {
      this.currentElementIndex--
    }

    // Deliberately update the visible element before removing the child. If we
    // remove the child first, the isSelected in updateVisibleElement will be
    // false, so the new currentElement won't actually be root.select()'ed.
    this.updateVisibleElement()
    this.removeChild(element)
    this.listElement.buildItems()
  }

  updateVisibleElement() {
    const len = this.tabberElements.length - 1
    this.currentElementIndex = Math.min(len, Math.max(0, this.currentElementIndex))

    this.tabberElements.forEach((el, i) => {
      el.visible = (i === this.currentElementIndex)
    })

    if (this.isSelected) {
      if (this.currentElement) {
        this.root.select(this.currentElement)
      } else {
        this.root.select(this)
      }
    }

    this.fixLayout()
  }

  selected() {
    if (this.currentElement) {
      this.root.select(this.currentElement)
    }
  }

  get selectable() {
    return this.currentElement && this.currentElement.selectable
  }

  get currentElement() {
    return this.tabberElements[this.currentElementIndex] || null
  }
}

class TabberList extends ListScrollForm {
  constructor(tabber) {
    super('horizontal')
    this.tabber = tabber
    this.captureTab = false
  }

  buildItems() {
    while (this.inputs.length) {
      this.removeInput(this.inputs[0])
    }

    for (const item of this.tabber.tabberElements) {
      const element = new TabberListItem(item, this.tabber)
      this.addInput(element)
      element.fixLayout()
      element.on('selected', () => this.emit('selected', item))
    }

    this.scrollToEnd()
    this.fixLayout()
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1
    this.x = 0
    this.y = 0
    this.scrollElementIntoEndOfView(this.inputs[this.curIndex])
    super.fixLayout()
  }

  drawTo() {
    let changed = false
    for (const input of this.inputs) {
      input.fixLayout()
      if (input._oldW !== input.w) {
        input._oldW = input.w
        changed = true
      }
    }
    if (changed) {
      this.fixLayout()
    }
  }

  clicked(button) {
    if (button === 'scroll-up') {
      this.emit('previous tab')
      return false
    } else if (button === 'scroll-down') {
      this.emit('next tab')
      return false
    }
  }

  // TODO: Be less hacky about these! Right now the tabber list is totally not
  // interactive.
  get curIndex() { return this.tabber.currentElementIndex }
  set curIndex(newVal) {}
}

class TabberListItem extends FocusElement {
  constructor(tab, tabber) {
    super()

    this.tab = tab
    this.tabber = tabber
  }

  fixLayout() {
    this.w = ansi.measureColumns(this.text) + 3
    this.h = 1
  }

  drawTo(writable) {
    if (this.tabber.currentElement === this.tab) {
      writable.write(ansi.setAttributes([ansi.A_BRIGHT]))
      writable.write(ansi.moveCursor(this.absTop, this.absLeft))
      writable.write('<' + this.text + '>')
      writable.write(ansi.resetAttributes())
    } else {
      writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1))
      writable.write(this.text)
    }
  }

  clicked(button) {
    if (button === 'left') {
      this.emit('selected')
      return false
    }
  }

  get text() {
    return this.tab.tabberLabel || 'a(n) ' + this.tab.constructor.name
  }
}

class ContextMenu extends FocusElement {
  constructor() {
    super()

    this.pane = new Pane()
    this.addChild(this.pane)

    this.form = new ListScrollForm()
    this.pane.addChild(this.form)

    this.visible = false
  }

  show({x = 0, y = 0, items}) {
    items = items.filter(Boolean)
    if (!items.length) {
      return
    }

    // This *should* work with a menu action which opens the menu again,
    // because the selected element will be restored before the menu is
    // opened the second time.
    this.selectedBefore = this.root.selectedElement

    this.clearItems()

    this.x = x
    this.y = y
    this.visible = true

    for (const item of items.filter(Boolean)) {
      if (item.element) {
        this.form.addInput(item.element)
      } else if (item.divider) {
        const element = new HorizontalRule()
        this.form.addInput(element)
      } else {
        const button = new Button(item.label)
        if (item.action) {
          button.on('pressed', () => {
            this.close()
            item.action()
          })
        }
        this.form.addInput(button)
      }
    }

    this.fixLayout()

    this.form.firstInput()
  }

  keyPressed(keyBuf) {
    if (telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) {
      this.close()
      return false
    } else {
      super.keyPressed(keyBuf)
    }
  }

  unselected() {
    this.selectedBefore = this.root.selectedElement
    this.close()
  }

  close() {
    this.clearItems()
    this.visible = false
    this.root.select(this.selectedBefore)
  }

  clearItems() {
    const inputs = this.form.inputs.slice()
    for (const input of inputs) {
      this.form.removeInput(input)
    }
  }

  fixLayout() {
    // Do an initial pass to determine the width of this menu (or in particular
    // the form), which is the greatest width of all the inputs.
    let width = 10
    for (const input of this.form.inputs) {
      input.fixLayout()
      width = Math.max(width, input.w)
    }

    // Then do a second pass to apply this width to every element, so that they
    // expand to all be the same width. In order to change the width of a
    // button (which is what these elements are), we need to append space
    // characters.
    for (const input of this.form.inputs) {
      const inputWidth = ansi.measureColumns(input.text)
      if (inputWidth < width) {
        input.text += ' '.repeat(width - inputWidth)
      }
      input.fixLayout()
    }

    let height = Math.min(10, this.form.inputs.length)

    width += 3 // Space for the pane border and scrollbar
    height += 2 // Space for the pane border
    this.w = width
    this.h = height

    this.fitToParent()

    this.pane.fillParent()
    this.form.fillParent()
  }

  selected() {
    this.root.select(this.form)
  }
}

class HorizontalRule extends FocusElement {
  // It's just a horizontal rule. Y'know..
  // --------------------------------------------------------------------------
  // You get the idea. :)

  get selectable() {
    // Just return false. A HorizontalRule is technically a FocusElement,
    // but that's just so that it can be used in place of other inputs
    // (e.g. in a ContextMenu).
    return false
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1
  }

  drawTo(writable) {
    // For the character we draw with, we use an ordinary dash instead of
    // an actual box-drawing horizontal line. That's so that the rule is
    // distinguishable from the edge of a Pane.
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
    writable.write('-'.repeat(this.w))
  }
}

module.exports.AppElement = AppElement
